package gateway.core;

import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpResponse;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.json.JSONArray;
import org.json.JSONObject;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.cloud.gateway.config.GatewayProperties;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.authentication.OAuth2AuthenticationManager;
import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService;
import org.springframework.security.oauth2.provider.token.RemoteTokenServices;
import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

import javax.sql.DataSource;
import java.net.InetSocketAddress;
import java.security.Principal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

@Configuration
@ComponentScan
@RestController
@EnableCircuitBreaker//enabled actuator '/hystrix.stream' for turbine aggregator
@SpringBootApplication
public class Application {

    private static String MgtAddr = "http://localhost:8080";

    private static final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();

    static {
        credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("user", "123"));
    }

    @RequestMapping("/hystrixfallback")
    public Map hystrixfallback(@RequestParam(required = false) String dependency) {
        Map result = new LinkedHashMap();

        result.put("state", "offline");

        return result;
    }

    @Bean
    KeyResolver remoteAddressKeyResolver() {
        return exchange -> {
            InetSocketAddress remoteAddress = exchange.getRequest().getRemoteAddress();
            return Mono.just(remoteAddress.getHostString() + ":" + remoteAddress.getPort());
        };
    }

    boolean isSpecialUser(String principalName, String[] specialUsers) {
        for (String user : specialUsers) {
            if (user.equals(principalName))
                return true;
        }
        return false;
    }

    String[] getSpecialUsers(final HttpHeaders headers) {
        List<String> pathRoutePredicate = headers.get("SpecialUsers");

        if (pathRoutePredicate != null && pathRoutePredicate.size() > 0) {
            return pathRoutePredicate.get(pathRoutePredicate.size() - 1).split(";");
        } else {
            return null;
        }
    }

    @Bean
    KeyResolver remoteAddressKeyResolver4Whitelist() {
        return exchange -> {
            boolean inWhitelist = false;
            Principal principal = exchange.getPrincipal().block();
            String[] specialUsers = getSpecialUsers(exchange.getRequest().getHeaders());

            if (principal != null && specialUsers != null && isSpecialUser(principal.getName(), specialUsers)) {
                inWhitelist = true;
            }

            if (inWhitelist) {
                return Mono.empty();
            } else {
                InetSocketAddress remoteAddress = exchange.getRequest().getRemoteAddress();
                return Mono.just(remoteAddress.getHostString() + ":" + remoteAddress.getPort());
            }
        };
    }

    @Bean
    KeyResolver principalNameKeyResolver4Whitelist() {
        return exchange -> {
            boolean inWhitelist = false;
            Principal principal = exchange.getPrincipal().block();
            String[] specialUsers = getSpecialUsers(exchange.getRequest().getHeaders());

            if (principal != null && specialUsers != null && isSpecialUser(principal.getName(), specialUsers)) {
                inWhitelist = true;
            }

            if (inWhitelist) {
                return Mono.empty();
            } else {
                return exchange.getPrincipal().map(Principal::getName).switchIfEmpty(Mono.empty());
            }
        };
    }

    static KeyResolver generateSpecialUserKeyResolver(String clientId) {
        return exchange -> {
            Principal principal = exchange.getPrincipal().block();

            if (principal != null && principal.getName().equalsIgnoreCase(clientId)) {
                InetSocketAddress remoteAddress = exchange.getRequest().getRemoteAddress();
                return Mono.just(remoteAddress.getHostString() + ":" + remoteAddress.getPort());
            } else {
                return Mono.empty();
            }
        };
    }

    @Component
    public class SpecialUserKeyResolverRegistry implements BeanFactoryAware {

        private ConfigurableBeanFactory beanFactory;

        @Override
        public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
            this.beanFactory = (ConfigurableBeanFactory) beanFactory;
        }

        public void configure() {
            System.out.println("SpecialUserKeyResolverRegistry#configure");
            try (CloseableHttpClient httpClient = HttpClients.custom().setDefaultCredentialsProvider(credentialsProvider).build()) {
                HttpGet httpGet = new HttpGet(MgtAddr + "/specialUser.json");

                String responseBody = httpClient.execute(httpGet, (HttpResponse response) -> EntityUtils.toString(response.getEntity()));
                JSONArray specialUsers = new JSONArray(responseBody);

                for (Object specialUser : specialUsers) {
                    JSONObject jsonSpecialUser = (JSONObject) specialUser;
                    String clientId = jsonSpecialUser.getString("clientId");
                    String requestPathMatchers = jsonSpecialUser.getString("requestPathMatchers");

                    for (String requestPath : requestPathMatchers.split(";")) {
                        String keyResolverBeanName = "specialUser_" + clientId + "_uri_" + requestPath.replaceAll("\\*", "").replaceAll("/", "_");
                        System.out.print("registerSingleton#" + keyResolverBeanName + "...");
                        beanFactory.registerSingleton(keyResolverBeanName, generateSpecialUserKeyResolver(clientId));
                        System.out.println("done");
                    }
                }
            } catch (Exception ex) {
                System.err.println(ex.getMessage());
            }
        }
    }

    @Bean
    @Primary
    @RefreshScope
    @ConfigurationProperties("spring.cloud.gateway")
    public GatewayProperties gatewayProperties() {
        return new GatewayProperties();
    }

    /**
     * https://github.com/spring-cloud/spring-cloud-gateway/issues/179#issuecomment-406238177
     */
    @Configuration
    protected static class OAuth2Configurer {

        @Autowired
        private DataSource dataSource;

        @Bean
        public ClientDetailsService userDetailsService() {
            return new JdbcClientDetailsService(dataSource);
        }

        @Bean("oauthAuthenticationWebFilter")
        public AuthenticationWebFilter oauthAuthenticationWebFilter(OAuth2AuthenticationManagerAdapter authManager, OAuthTokenConverter tokenConverter) {
            AuthenticationWebFilter filter = new AuthenticationWebFilter(authManager);
            filter.setAuthenticationConverter(tokenConverter);
            return filter;
        }

        @Bean
        public ResourceServerTokenServices tokenService() {
            RemoteTokenServices tokenServices = new RemoteTokenServices();
            tokenServices.setClientId("acme");
            tokenServices.setClientSecret("acmesecret");
            tokenServices.setCheckTokenEndpointUrl("http://localhost:6543/oauth/check_token");
            return tokenServices;
        }
    }

    @Component
    protected static class OAuth2AuthenticationManagerAdapter implements ReactiveAuthenticationManager {

        private final AuthenticationManager authenticationManager;

        @Autowired
        public OAuth2AuthenticationManagerAdapter(ResourceServerTokenServices tokenServices, ClientDetailsService userDetailsService) {
            this.authenticationManager = oauthManager(tokenServices, userDetailsService);
        }

        public Mono<Authentication> authenticate(Authentication token) {
            return Mono.just(token).publishOn(Schedulers.elastic()).flatMap(t -> {
                try {
                    return Mono.just(this.authenticationManager.authenticate(t));
                } catch (Exception x) {
                    return Mono.error(new BadCredentialsException("Invalid or expired access token presented"));
                }
            }).filter(Authentication::isAuthenticated);
        }

        private AuthenticationManager oauthManager(ResourceServerTokenServices tokenServices, ClientDetailsService userDetailsService) {
            OAuth2AuthenticationManager oauthAuthenticationManager = new OAuth2AuthenticationManager();
            oauthAuthenticationManager.setResourceId("");
            oauthAuthenticationManager.setTokenServices(tokenServices);
            oauthAuthenticationManager.setClientDetailsService(userDetailsService);
            return oauthAuthenticationManager;
        }

    }

    @Component
    protected static class OAuthTokenConverter implements Function<ServerWebExchange, Mono<Authentication>> {

        private static final String BEARER = "bearer ";

        @Override
        public Mono<Authentication> apply(ServerWebExchange exchange) {
            String token = extractToken(exchange.getRequest());
            if (token != null) {
                return Mono.just(new PreAuthenticatedAuthenticationToken(token, ""));
            }
            return Mono.empty();
        }

        private String extractToken(ServerHttpRequest request) {
            String token = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
            if (StringUtils.isBlank(token) || !token.toLowerCase().startsWith(BEARER)) {
                return null;
            }
            return token.substring(BEARER.length());
        }
    }

    /**
     * The configuration defined here is what really drives the edge service.
     * Any request that doesn't get handled by the other two configs will be
     * handled by this one. All requests coming through here must have a
     * valid access token
     */
    @Bean
    @RefreshScope
    SecurityWebFilterChain oauthTokenAuthConfig(ServerHttpSecurity security, AuthenticationWebFilter oauthAuthenticationWebFilter, SpecialUserKeyResolverRegistry specialUserKeyResolverRegistry) {

        specialUserKeyResolverRegistry.configure();

        ServerHttpSecurity.AuthorizeExchangeSpec spec = security
                .csrf().disable()
                .logout().disable()
                .httpBasic().disable()
                .formLogin().disable()
                .exceptionHandling().and()
                .securityMatcher(notMatches("/unsecuredRoutes/**"))
                .addFilterAt(oauthAuthenticationWebFilter, SecurityWebFiltersOrder.HTTP_BASIC)
                .authorizeExchange();

        try (CloseableHttpClient httpClient = HttpClients.custom().setDefaultCredentialsProvider(credentialsProvider).build()) {
            HttpGet httpGet = new HttpGet(MgtAddr + "/apiDefinition.json");

            String responseBody = httpClient.execute(httpGet, (HttpResponse response) -> EntityUtils.toString(response.getEntity()));
            JSONArray jsonApiDefinitions = new JSONArray(responseBody);

            for (Object api : jsonApiDefinitions) {
                JSONObject jsonApiDef = (JSONObject) api;
                String path = jsonApiDef.getJSONObject("request").getString("path");

                JSONArray apiAuthIds = jsonApiDef.getJSONArray("apiAuths");
                for (int idx = 0; idx < apiAuthIds.length(); idx++) {
                    String authority = "ApiAuth#" + apiAuthIds.getJSONObject(idx).getInt("id");
                    System.out.println("Path[" + path + "] authorized Authority[" + authority + "]");
                    spec.pathMatchers(path).hasAuthority(authority);
                }
            }
        } catch (Exception ex) {
            System.err.println(ex.getMessage());
        }

        return spec
                .anyExchange().hasAuthority("ROLE_TRUSTED_CLIENT")
                .and()
                .build();
    }

    private ServerWebExchangeMatcher matches(String... routes) {
        return ServerWebExchangeMatchers.pathMatchers(routes);
    }

    private ServerWebExchangeMatcher notMatches(String... routes) {
        return new NegatedServerWebExchangeMatcher(matches(routes));
    }

    /*
    https://github.com/spring-projects/spring-security/blob/5.1.0.M2/web/src/main/java/org/springframework/security/web/server/util/matcher/NegatedServerWebExchangeMatcher.java
     */
    protected class NegatedServerWebExchangeMatcher implements ServerWebExchangeMatcher {
        private final ServerWebExchangeMatcher matcher;

        public NegatedServerWebExchangeMatcher(ServerWebExchangeMatcher matcher) {
            Assert.notNull(matcher, "matcher cannot be null");
            this.matcher = matcher;
        }

        /* (non-Javadoc)
         * @see org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher#matches(org.springframework.web.server.ServerWebExchange)
         */
        @Override
        public Mono<MatchResult> matches(ServerWebExchange exchange) {
            return matcher.matches(exchange)
                    .flatMap(m -> m.isMatch() ? MatchResult.notMatch() : MatchResult.match());
        }

        @Override
        public String toString() {
            return "NegatedServerWebExchangeMatcher{" + "matcher=" + matcher + "}";
        }
    }

    public static void main(String[] args) {
        ApplicationContext context = SpringApplication.run(Application.class, args);

        String coreName = context.getEnvironment().getProperty("spring.application.name");
        String servPort = context.getEnvironment().getProperty("server.port");

        //boot heartbeat provider
        new HeartbeatProvider(MgtAddr, "admin", "adminadmin", coreName, coreName + ":" + servPort);
    }
}
